계층적 스트리밍 파이프라인 설계로 OOM 방지
NOTE
대량 데이터 생성 시 발생하는 OOM을, “파일 기반 전체 로드”(File-first) 방식에서 “Context 기반 계층적 Stream 처리”(Data-driven)로 전환해 해결한 설계 패턴. “Init Data 생성 Tool” 미니프로젝트의 고도화 작업에서 추출. 관련 노트: [(Spring) 동적 템플릿 엔진 설계 패턴 (계층화·SpEL·Chain of - 핵심 개념 및 특징 정리](../../../%ED%94%84%EB%A0%88%EC%9E%84%EC%9B%8C%ED%81%AC/Spring%20Framework/%EC%8B%A4%EC%8A%B5_%EC%8A%A4%ED%94%84%EB%A7%81MVC/[Spring]%20%EB%8F%99%EC%A0%81%20%ED%85%9C%ED%94%8C%EB%A6%BF%20%EC%97%94%EC%A7%84%20%EC%84%A4%EA%B3%84%20%ED%8C%A8%ED%84%B4%20(%EA%B3%84%EC%B8%B5%ED%99%94%C2%B7SpEL%C2%B7Chain%20of%20-%20%ED%95%B5%EC%8B%AC%20%EA%B0%9C%EB%85%90%20%EB%B0%8F%20%ED%8A%B9%EC%A7%95%20%EC%A0%95%EB%A6%AC.md) — 같은 도구의 템플릿 확장 설계
⚙️ 문제 진단 — OOM 발생 원인
전체 데이터 생성 로직이 모든 하위 단위(예: 가맹점 N건 × 결제 100건)를 List에 전부 담아 한꺼번에 반환하는 구조라면, 데이터 건수가 늘어날수록 JVM Heap 메모리가 부족해져 프로세스가 중단된다.
⚙️ 아키텍처 개선 — “전체 로드 후 처리” → “필요할 때 생성하고 즉시 처리 후 버리기”
- Lazy Loading 도입:
List대신Stream과flatMap을 사용해 데이터를 하나씩 순차적으로 생성 - 계층적 처리 구조: 상위 → 하위 순서로 중첩된 스트림을 통해 메모리 점유율을 “현재 처리 중인 상위 단위 1건 + 하위 단위 1건” 수준으로 최소화
- 템플릿 사전 캐싱: 정적 리소스(JSON 템플릿 등)를 매번 읽지 않도록 Registry에 미리 로드해 I/O 성능 향상
⚙️ 설계 원칙 — File-first vs Data-driven
File-first 구조의 문제
- 파일 기준 반복
- 모든 하위 데이터를 메모리에 적재
- 대량 데이터에서 OOM 위험
- 흐름이 비즈니스 계층과 맞지 않음
Data-driven 구조로 전환: 데이터의 생성 순서(상위 개념 → 중간 개념 → 최하위 개념, 예: 계약 → 제휴사 → 가맹점 → 결제)를 코드 흐름에 그대로 반영. 각 단계는 상위 Context를 기반으로 스트리밍 처리한다.
⚙️ 전체 파이프라인 구조
1. Ready Phase — 템플릿 사전 로드: 템플릿은 용량이 작으므로 초기에 한 번만 메모리에 적재.
Map<Division, List<MetaData>> templates;2. 상위 Scope 처리(기반 데이터): 계약/제휴사 정보 등, 이후 하위 단계 생성의 Context 역할.
templates.get(AFFILIATE).forEach(t -> process(t, cpidContext));3. 중간 Scope 처리(루프): Assembler는 더 이상 List를 반환하지 않고 Stream<XxxContext> 제공.
assembler.streamMids(cpids).forEach(midContext -> {
registry.process(Division.MERCHANT, midContext);
// 다음 Scope로 이동
});4. 최하위 Scope 처리(결제 등): 현재 상위 컨텍스트 기준으로 하위 데이터를 하나씩 생성.
assembler.streamPayData(midContext).forEach(payData -> {
registry.process(Division.PAY, midContext, payData);
});
// 이 시점에서 payData는 메모리에서 해제됨(즉시 GC 대상)Conceptual Class Structure
public class DataGenerationPipeline {
public void execute() {
// 1. 템플릿 로드 (파일 IO는 처음에 한 번만)
TemplateRegistry registry = templateLoader.loadAll();
// 2. 기반 데이터 처리
List<CpidMap> cpids = assembler.getBaseCpids();
cpids.forEach(cpid -> registry.process(Division.AFFILIATE, cpid));
// 3. 중간 단위 스트리밍 (OOM 방지 핵심)
assembler.streamMids(cpids).forEach(midContext -> {
registry.process(Division.MERCHANT, midContext);
// 4. 최하위 단위 스트리밍
assembler.streamPayData(midContext).forEach(payData ->
registry.process(Division.PAY, midContext, payData)
);
// 이 시점에서 해당 midContext 관련 payData는 메모리에서 해제됨
});
}
}⚙️ 구조적 장점
- 메모리 효율성: 데이터 규모와 무관하게 메모리 상주 객체는 “현재 처리 중인 상위 1건 + 하위 1건” 수준으로 일정
- 가독성: 데이터 생성 흐름이 코드 구조와 동일 — 비즈니스 계층과 일치
- 트랜잭션 제어 용이: 상위 단위/하위 단위로 트랜잭션 분리·묶기가 쉬움
- 확장성: 새로운 분류(Division) 추가 시 어떤 Scope에 속하는지만 결정하면 기존 파이프라인에 바로 편입 가능
🔁 최종 아키텍처 철학
“데이터는 저장하지 말고 흘려보내라.” — 거대한 DTO를 제거하고 Assembler를 “데이터 공급자(Stream Provider)” 역할로 바꾼다. 상태 보관을 최소화하고, 객체 생명주기를 단축하며, 계층적 Context 기반의 스트리밍 중심 설계를 지향한다.